テストケースを動的に生成してJUnitで実行する
AWSチームに参画して2ヶ月ほど経ちました。ところが、AWSの構築などにはあまり関わらず、ひたすらAWSに関連するプロダクトの開発を行う毎日です。そんな折、ボスより次のようなリクエストをいただきました。
ユーザが参照できない情報について、参照できないことを検証して欲しい
・・・「出来ないことの検証」です。
「出来ることの検証」であれば、その例をテストケースとして記述してテストを実行すれば検証出来ます。しかし、出来ないことを証明することは非常に困難です。ただ、情報は有限なんで、総当たりにでもやればできるかもしれません。
!?
システムのインフラは当然のようにAWSです。テストのためのリソースが足りなければ増やせばいいじゃないですか。時間がかかるならば並列化すればいいじゃないですか。テストの時だけ増やせばいいんです。
ならば、総当たりでテストしよう
という方針になりました。そして、ブログのネタ決定です。
JUnitの設計思想
総当たりのテストケースを個別に書いていくのは生産的ではありません。また、情報は更新されますから、常に最新の状態で総当たりテストをしたいです。つまり、テストケースは動的に生成しなければなりません。ところが、動的に生成されるテストケースとJUnitのフレームワークとしての設計思想は相反します。
はじめにJUnitの設計思想を確認しましょう。
JUnitはxUnit系のテスティングフレームワークです。テストケースはテストクラスのテストメソッドとして定義し、テストを実行します。これは非常にシンプルで有効な設計です。
JUnitを使ってテストコードを書く場合、テスト対象クラスに対して、ペアとなるテストクラスを作成します。このため、テスト対象クラスとテストクラスで見通しが良くなります。また、テストケース毎にテストメソッドが作成されるため、IDEなどでテストケースの一覧を参照する事も容易です。これは、JUnitがクラスやメソッドを対象として、それらが期待される振る舞いをするかを検証するユニットテストのために作られたフレームワークだからです。
テスト対象クラスも特になく、動的にテストケースを生成して実行するならば、JUnitを使わないことも選択肢のひとつです。ですが、JUnitは周辺のツールが充実しているため、JUnitの仕組みに乗せてしまった方が都合が良いのです。特にJUnitは、IDEやJenkinsなどのCIツールとの相性が良いため、強引にJUnitの仕組みに乗せる価値があります。ただし、テストケースがクラス毎にメソッド単位で定義されているという設計思想を念頭におく必要があります。
Runner
JUnitのorg.junit.runner.Runnerクラスは、どのようなテストケースを実行するかを制御するクラスです。また、テスト実行時にイベントをorg.junit.runner.notification.RunNotiferオブジェクトに通知します。今回はカスタムのRunnerを作成して動的なテストケースを実行させてみたいと思います。
はじめにRunnerクラスのサブクラスを作りましょう。
public class DynamicTestsRunner extends Runner { public DynamicTestsRunner(Class<?> testClass) { } @Override public Description getDescription() { // TODO Auto-generated method stub return null; } @Override public void run(RunNotifier notifier) { // TODO Auto-generated method stub } }
コンストラクタ
テストクラスのクラスオブジェクトを引数に持つコンストラクタが必要です。JUnitの設計思想を踏まえればテストクラスにテストケースに関する情報が宣言されていることになります。今回は無視します。
getDescriptionメソッド
RunnerクラスのgetDescriptionメソッドは、テストの情報をorg.junit.runner.Description.Descriptionオブジェクトとして返すメソッドです。詳しくは後述します。
runメソッド
Runnerクラスのrunメソッドは、テストを実行するメソッドです。引数としてRunNotifierオブジェクトを取り、テストの実行前や実行後、失敗時などにイベントを発火しなければなりません。RunNotifierオブジェクトのイベントを発火すると、テスト結果の集計などを行うリスナーオブジェクトに伝搬されます。
getDescriptionメソッドの実装
Descriptionクラスは、テストケースやテストスイートの情報を表します。しかし、テストケースの場合とテストスイートの場合で扱いが異なります。
Descriptionオブジェクトがテストケースの場合、createTestDescription
メソッドを利用して生成します。createTestDescription
メソッドはDescriptionのstaticファクトリメソッドで、第1引数にテストクラス名、第2引数にテストメソッド名を指定します。ここでは、動的なテストケースを作成するので、仮のテストクラス名と仮のテストメソッド名を次のように指定することにします。
Description.createTestDescription("DynamicTests", "Test-1");
Descriptionオブジェクトがテストスイートの場合、createSuiteDescription
メソッドを利用して生成します。createSuiteDescription
メソッドはDescriptionのstaticファクトリメソッドで、第1引数にテストクラス名を指定します。先ほどと同様に仮のクラス名を指定すると次のようになります。
Description.createSuiteDescription("DynamicTests");
さらに、テストスイートの場合、子となるテストケース(またはテストスイート)のDescriptionオブジェクトを入れ子として登録しなければなりません。
これらを整理すると次のようになります。
@Override public Description getDescription() { Description desc = Description.createSuiteDescription("DynamicTests"); desc.addChild(getDescription("Test-1")); desc.addChild(getDescription("Test-2")); desc.addChild(getDescription("Test-3")); return desc; } private Description getDescription(String testName) { return Description.createTestDescription("DynamicTests", testName); }
runメソッドの実装
runメソッドを実装する場合に注意すべきことは、RunNotifierに適切なイベントを通知することです。IDEやJenkinsなどではRunNotifierオブジェクトから伝搬されるリスナーがテストの実行状態を監視しています。したがって、イベントの通知が不完全であると、テスト結果が期待通りに表示されません。
今回、runメソッドでは複数のテストケースを実行します。このため、はじめにテストスイートのDescriptionオブジェクトを指定してfireTestStartedメソッドを呼び出します。その後は、各テスト毎にfireTestStarted
メソッドを実行します。テストが終わったならばfireTestFinished
メソッドを実行し、すべてのテストが実行したならばテストスイートのDescriptionオブジェクトを指定してfireTestFinished
メソッドを実行します。なお、テストが失敗した場合はAssertionErrorがthrowされるので、catchしてfireTestFailure
メソッドを実行します。
@Override public void run(RunNotifier notifier) { Description desc = getDescription(); notifier.fireTestStarted(desc); invokeTest(notifier, "Test-1"); invokeTest(notifier, "Test-2"); invokeTest(notifier, "Test-3"); notifier.fireTestFinished(desc); } private void invokeTest(RunNotifier notifier, String testCase) { Description desc = getDescription(testCase); notifier.fireTestStarted(desc); try { // TODO 実際にテストする System.out.println("Execute: " + desc); } catch (AssertionError e) { notifier.fireTestFailure(new Failure(desc, e)); } finally { notifier.fireTestFinished(desc); } }
最後にテストケースをコンストラクタで生成するようにリファクタリングすると、次のようになります。
public class DynamicTestsRunner extends Runner { List<String> testCases = new LinkedList<>(); public DynamicTestsRunner(Class<?> testClass) { for (int i = 0; i < 20; i++) { testCases.add("Test-" + i); } } @Override public Description getDescription() { Description desc = Description.createSuiteDescription("DynamicTests"); for (String testCase : testCases) { desc.addChild(getDescription(testCase)); } return desc; } @Override public void run(RunNotifier notifier) { Description desc = getDescription(); notifier.fireTestStarted(desc); for (String testCase : testCases) { invokeTest(notifier, testCase); } notifier.fireTestFinished(desc); } private Description getDescription(String testName) { return Description.createTestDescription("DynamicTests", testName); } private void invokeTest(RunNotifier notifier, String testCase) { Description desc = getDescription(testCase); notifier.fireTestStarted(desc); try { if (testCase.equals("Test-4")) fail("sorry failed."); System.out.println("Execute: " + desc); } catch (AssertionError e) { notifier.fireTestFailure(new Failure(desc, e)); } finally { notifier.fireTestFinished(desc); } } } [/java] <p>これで、テストケースを動的に生成し実行するテストランナーが完成しました。失敗も確認出来るようにしています。</p> <h2 id="toc-">テストの実行</h2> <p>テストの実行を行うにはダミーとなるテストクラスを用意し、<strong>RunWithアノテーションでテストランナーを指定</strong>すればと、IDEなどのツールで簡単に実行できます。右クリックして「Run with JUnit Test」です。</p> @RunWith(DynamicTestsRunner.class) public class DynamicTests { }
DynamicTestsRunnerはテストメソッドからテストケースを実行するテストランナーではないため、テストメソッドは必要ありません。
Eclipseで実行すると次のように表示されます。
後は、全ての情報を取り込んで、テストケースを動的に作れば、総当たりのテストがJUnitで実行出来ます。実行はJenkinsのジョブとして登録しておけば、ボスも安心して眠れることでしょう。そして、アラートメールで起きる事にならないことを祈ります。
なお、今回行ったテストはセキュリティのテストです。実際のテストコードでは、HttpClientなどを利用して本番システムにHTTPアクセスし、「参照できない情報にアクセスできないこと」を順次チェックするような実装となっています。その目的はセキュリティの検証であり、対象はAWS上の完全なシステムです。
このように、JUnitはユニットテストに最適なテスティングフレームワークですが、ユニットテスト以外のテストにも利用することができます。JUnitは便利なツールですね!
というわけで、今回はRunnerクラスを使い動的にテストケースを作成する方法を紹介しました。JUnit実践入門では扱いきれなかった応用的な話題はこれからも紹介していく予定です。